主题
Go 依赖注入:谁创建对象,谁把依赖交给它,谁负责收尾
报价服务需要一个价格库。最直接的写法是让服务自己打开数据库,但这样测试、命令行程序和 HTTP 程序都得跟着它的初始化方式走。依赖注入把这一步移到外面:服务声明需要什么,由程序入口把已经准备好的依赖传进去。
这件事在 Go 里通常不需要框架。本文从一个普通构造函数开始,再处理两个更容易漏的问题:构造到一半失败怎么办,正常退出时按什么顺序释放资源。示例固定 Go 1.27.2、Linux/amd64。
先让参数说清依赖
服务只需要“按 key 获取内容”,因此消费方定义一个方法的小接口:
go
type Store interface {
Get(context.Context, string) (string, error)
}
func NewService(store Store) (*Service, error) {
if store == nil { return nil, errors.New("store is required") }
return &Service{store: store}, nil
}构造函数接收 Store,返回具体 *Service。Go 的接口实现是隐式的;生产存储和测试替身只要满足方法集,就能传进来,不需要在接口所属包登记。接口规则
这里使用接口是因为确实存在“获取数据”这个变化边界,不是因为每个 struct 都必须配一个 interface。如果组件只依赖一个不会替换的具体对象,直接传 *Config 或 *Client 完全可以。消费方定义小接口、实现方通常返回具体类型,也是 Go 官方审查建议。Interfaces
| 写法 | 依赖在哪里出现 | 收益 | 代价或问题 |
|---|---|---|---|
| 构造函数参数 | NewService(store) | 调用处一眼看见依赖,容易替换和测试 | 入口需要维护组装代码 |
| 方法参数 | Lookup(ctx, store, key) | 适合每次操作不同的临时能力 | 长期依赖反复传递会啰嗦 |
| 包级可变变量 | var defaultStore | 调用短 | 测试互相影响,生命周期和替换时机隐藏 |
| service locator | container.Get("store") | 隐藏具体组装 | 依赖藏进运行时查询,类型与缺失错误更难追踪 |
后两种不是语言禁止的写法;这里倾向显式参数,是为了降低本场景的隐藏状态。Wire 原始问题说明、Uber 的可变全局状态建议
在入口处把依赖图接起来
正在绘制图示…
查看图源代码
flowchart LR
B["Build:程序组装入口"] --> S["打开 store"]
S --> V["NewService(store)"]
B --> C["打开 cache"]
V --> A["App 持有 Service"]
C --> A
S --> A
图表示本例的创建和持有关系。Service 只依赖 Store;cache 是 App 持有的第二个模拟资源,用于演示清理顺序,没有伪装成已经接入真实缓存服务。所谓“组装入口”只是集中创建和连接对象的地方,常在 main 附近,不是一种需要安装的新组件。
附录 Build 的成功路径打开 store、构造 service、打开 cache,再返回 App 及其 Close 能力。Example 测试实际产生并核对:
text
open store
open cache
value:pen <nil>
close cache
close store依赖从参数进入、结果从方法返回,测试可以直接看到这条链路。等将来换存储实现,改的是入口的构造与资源配置,服务不必知道连接串或驱动类型。
不要把 context 当通用依赖容器,把 Store、Logger、Config 全部塞进 ctx.Value。context 的主要用途是取消、deadline 和请求范围内的值;长期依赖用字段与构造函数更容易检查。context 使用规则
初始化失败时,已经创建的资源也要释放
最危险的路径不是全部成功,而是“数据库已经连上,后面的组件初始化失败”。若直接 return,前面打开的资源就留在进程里。
本例模拟 handler 组装失败,先关闭已打开的资源,再把初始化错误传回去。实际输出:
text
open store
open cache
close cache
close store
handler setup failed核心做法是保存一个清理函数,并合并清理错误:
go
cleanup := func() error {
return errors.Join(cache.Close(), store.Close())
}
if failHandler {
return nil, errors.Join(errors.New("handler setup failed"), cleanup())
}errors.Join 允许调用方继续通过 errors.Is/As 判断包含的错误。例子的 Close 总是成功;合并写法保留了将来真实资源关闭失败时的诊断空间。errors.Join
本文建议初始化顺序与释放责任一起设计,通常按构造的逆序清理,因为后创建的对象更可能依赖先创建的对象。但资源间没有依赖时,顺序可以按其真实需要安排;“逆序”不是对所有系统的普遍证明。
正常运行时,App 的所有者负责调用一次 Close;构造失败时,Build 自己完成已创建部分的清理。这个归属要唯一,不能一边让 App 管连接池,一边又让每个 Service 随手关闭它。示例 Close 做了简单的重复关闭保护,但不承诺并发 Close 安全;它由单个生命周期所有者调用。
构造函数里的 nil 检查有明确边界
go
var p *Resource
var dep Store = p
fmt.Println(p == nil, dep == nil)本例测试日志:
text
typed pointer nil=true interface nil=false接口值持有动态类型与动态值;动态类型是 *Resource 时,即使指针值为 nil,接口本身也不等于 nil。因此 NewService 的 store == nil 只能抓住 nil 接口,不能自动拒绝所有“带类型 nil”。Go FAQ
不要为了一个构造函数立即引入反射通用判空器。更直接的选择是:入口保证只有成功构造的对象才传入;必要时接收具体指针;或者把“依赖实现不得是不可用的 typed nil”写成接口契约。测试应覆盖实际可能出现的错误注入方式。本文没有调用这个 typed nil 依赖,只验证比较行为。
循环依赖通常先改职责,再挑工具
如果 A 构造需要 B,B 构造又需要 A,最容易想到的是先创建空对象,再 SetA/SetB。这样可能得到一段“对象存在但还不能使用”的窗口,初始化顺序也更难测试。
本文建议先查清两者为什么互相需要:
- 它们共同维护一个不变量,可以合并为同一组件。
- 它们需要的是对方少量能力,可以把接口放在实际消费方,由外部组装。
- 双方都需要通知第三件事,可以提取协调者或显式事件边界。
这不意味着所有运行时对象图都必须是 DAG。回调、观察者等允许双向关系;要说清注册完成前谁可以调用谁、退出时如何解除关系。自动生成工具能检查或连线某种依赖图,不能替你定义对象何时有效。
什么时候才值得引入代码生成
手工组装的成本随着重复图和分支增多而上升:多个命令都要创建一组相同基础设施,不同模式又各换一部分实现。此时可比较自动化收益与维护成本,而不是只数构造函数有多少行。
| 方式 | 适合什么变化 | 新增责任 |
|---|---|---|
| 手工构造 | 图较小或依赖关系需要细致资源控制 | 保持组装入口清楚、减少复制 |
| 代码生成 | 大量可静态描述、重复的组装图 | 固定生成器版本、保留生成输入、检查生成差异与再生成结果 |
| 运行时容器 | 明确需要运行时注册和生命周期管理 | 理解容器错误、初始化顺序、调试和关闭语义 |
Wire 是历史上常见的编译期连线工具:provider 以普通函数表达,injector 声明要组装的结果,生成代码仍是普通 Go 调用,不需要运行时反射容器。这是它的设计特点,不等于当前仍被积极维护。Wire 设计
截至 2026-10-13,google/wire 官方仓库显示 2025-08-25 归档,README 声明不再维护。 已有项目可以保留和审查生成代码,但新项目不要仅凭历史知名度把它列为默认依赖。确实选用 fork 或其他生成器时,要重新核查维护者、版本、Go 兼容性和安全边界;不能把“社区有 fork”理解为所有 fork 都得到原作者背书。Wire 官方状态
手工注入是本篇给新项目的起点。需要自动化时,先保留“参数表明依赖、错误能返回、资源能释放”这三个性质,再判断哪种工具减少了重复劳动。
资料与验证
- Go 接口规范、接口审查惯例、context、errors.Join、nil 接口 FAQ。
- Wire 原始设计、官方仓库维护状态:分别说明历史机制和当前归档情况,二者不可混用。
2026-10-13 在 Go 1.27.2、Linux/amd64 运行成功与部分失败的生命周期示例、typed nil 测试、race、vet。资源为内存中的教学对象,不是真实数据库或缓存;没有运行 Wire 或其他 DI 框架。生产系统还需要处理服务退出信号、等待 goroutine、停止接收新请求和有时限的关闭,这些不由构造函数注入自动解决。
完整代码
保存以下文件,执行 go test -count=1 -v ./...、go test -count=1 -race ./...、go vet ./...。Example 测试会核对上面的完整输出序列。
app.go
代码块 · 67 行 · 点击收起 / 展开
go
package di
import (
"context"
"errors"
"fmt"
"io"
)
type Store interface {
Get(context.Context, string) (string, error)
}
type Service struct{ store Store }
func NewService(store Store) (*Service, error) {
if store == nil {
return nil, errors.New("store is required")
}
return &Service{store: store}, nil
}
func (s *Service) Lookup(ctx context.Context, key string) (string, error) {
return s.store.Get(ctx, key)
}
type Resource struct {
out io.Writer
name string
closed bool
}
func Open(out io.Writer, name string) *Resource {
fmt.Fprintln(out, "open", name)
return &Resource{out: out, name: name}
}
func (r *Resource) Get(ctx context.Context, key string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return "value:" + key, nil
}
func (r *Resource) Close() error {
if !r.closed {
fmt.Fprintln(r.out, "close", r.name)
r.closed = true
}
return nil
}
type App struct {
Service *Service
close func() error
}
func (a *App) Close() error { return a.close() }
func Build(out io.Writer, failHandler bool) (*App, error) {
store := Open(out, "store")
service, err := NewService(store)
if err != nil {
return nil, errors.Join(err, store.Close())
}
cache := Open(out, "cache")
cleanup := func() error { return errors.Join(cache.Close(), store.Close()) }
if failHandler {
return nil, errors.Join(errors.New("handler setup failed"), cleanup())
}
return &App{Service: service, close: cleanup}, nil
}app_test.go
代码块 · 56 行 · 点击收起 / 展开
go
package di
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
)
func ExampleBuild() {
var out bytes.Buffer
app, err := Build(&out, false)
if err != nil {
panic(err)
}
value, err := app.Service.Lookup(context.Background(), "pen")
fmt.Fprintln(&out, value, err)
if err := app.Close(); err != nil {
panic(err)
}
fmt.Print(out.String())
out.Reset()
_, err = Build(&out, true)
fmt.Print(out.String())
fmt.Println(err)
// Output:
// open store
// open cache
// value:pen <nil>
// close cache
// close store
// open store
// open cache
// close cache
// close store
// handler setup failed
}
func TestTypedNil(t *testing.T) {
var p *Resource
var dep Store = p
if dep == nil {
t.Fatal("expected non-nil interface")
}
// Do not use this dependency: nil interface checks cannot reject every typed nil.
t.Logf("typed pointer nil=%v interface nil=%v", p == nil, dep == nil)
}
func TestFailureCleanup(t *testing.T) {
var out bytes.Buffer
if _, err := Build(&out, true); err == nil {
t.Fatal("expected error")
}
if !strings.HasSuffix(out.String(), "close cache\nclose store\n") {
t.Fatal(out.String())
}
}go.mod
go.mod
module example.com/v2/di
go 1.27.2